Проект "Анализ оттока клиентов банка "Метанпромбанк""¶

Описание проекта: банк "Метанпромбанк" нуждается в анализе оттока клиентов. Анализ покажет, какие клиенты уходят из банка, а так же поможет нам составить сегменты клиентов, которые склонны уходить из банка. Привлекать новых клиентов дорого, дешевле удержать тех, про которых мы уже что-то знаем и с которыми работаем. В отделе маркетинга банка нет автоматизированных систем рассылок, письма каждому клиенту пишутся вручную, поэтому нам важно для отдела маркетинга представить компактные однородные сегменты и дать примеры мероприятий, которые можно провести, чтобы вернуть клиентов в банк или удержать сомневающихся от оттока.

Ссылка на презентацию: PDF

Цель проекта: изучить пользователей банка и сформулировать рекомендации отделу маркетинга по возврату ушедших клиентов и удержанию тех, кто находится в “зоне риска” и склонны к уходу.

Задачи проекта:

  • Провести исследовательский анализ данных, определить все значимые признаки отточности (интервалы значений характеристик, которые связаны с повышенным оттоком), сравнить портреты типичных клиентов, которые склонны и не склонны уходить из банка.
  • Сформулировать и проверить статистические гипотезы.
  • Объединяя признаки отточности, сформировать сегменты, отобрать из них лучшие и самые компактные и дать по ним рекомендации.

Используемые инструменты

  • python

  • pandas

  • numpy

  • scipy

  • matplotlib

  • seaborn

  • plotly

  • phik

План исследования

  1. Загрузка и предобработка данных.

    1. Загрузка данных и основная информация.
    2. Приведение столбцов к snake_case.
    3. Обработка пропусков.
    4. Обработка дубликатов.
    5. Преобразование типов данных.
    6. Кодирование категориальных переменных.
    7. Проверить значения столбцов на наличие явных ошибок и аномалий.
    8. Проверить распределения количественных переменных, при необходимости отфильтровать выбросы.
  2. Исследовательский анализ данных.

    1. Составить и сравнить портреты типичного остающегося и уходящего пользователя. Рассчитать среднее и медианное значение для остающихся и уходящих клиентов, моду для категориальных признаков.

    2. Проанализировать распределение непрерывных переменных для остающихся и уходящих клиентов и выявить интервалы с повышенным оттоком.

    3. Рассчитать процент уходящих клиентов по категориальным признакам и выявить влияние этих признаков на отток:

       - город;
       - возраст;
       - количество баллов собственности;
       - количество продуктов, которыми пользуется клиент;
       - наличие кредитной карты;
       - активность.
      
      
    4. Провести корреляционный анализ.

    5. Сформулировать промежуточный вывод о признаках и интервалах значений, которые значимо влияют на отток клиентов.

  3. Проверка статистических гипотез.

    1. Проверить гипотезу различия дохода между теми клиентами, которые ушли и теми, которые остались.

    2. Проверить гипотезу о более высоком балле кредитного скоринга уходящих клиентов относительно остающихся.

    3. Проверить гипотезу о более высоком балансе уходящих клиентов относительно остающихся.

  4. Провести сегментацию клиентов, склонных к оттоку, на основе показателей, выявленных на этапе исследовательского аналтза данных. Приоритизировать и описать полученные сегменты.

  5. Общий вывод. Сформулировать рекомендации для отдела маркетинга. Подготовить презентацию на основе проведённого исследования.

Описание данных

Датасет bank_scrooge.csv содержит данные о клиентах банка «Метанпром».

Банк располагается в Ярославле и областных городах: Ростов Великий и Рыбинск.

Колонки:

USERID — идентификатор пользователя.

score — баллы кредитного скоринга.

city — город.

gender — пол.

age — возраст.

equity — количество баллов собственности.

balance — баланс на счёте.

products — количество продуктов, которыми пользуется клиент.

credit_card — есть ли кредитная карта.

last_activity — активный клиент.

EST_SALARY — оценочный доход клиента.

сhurn — признак оттока.

Комментарий тимлида 😊

Очень подробное введение к проекту, молодец!)

Вспомогательные функции¶

In [5]:
#функция для вывода гистограммы, диаграммы размаха и основных характеристик столбца
def description(df, col, bins=100):
    try:
        plt.figure(figsize=(15,10))

        #гистограмма распределения
        ax1 = plt.subplot(2,1, 1)
        ax1 = sns.histplot(df[col], bins=bins)
        ax1.set_title('Гистограмма значений столбца')
        ax1.set_xlabel('Значения')
        ax1.set_ylabel('Частота')

        #диаграмма размаха
        ax2 = plt.subplot(2,1, 2, sharex=ax1)
        ax2 = sns.violinplot(x=col, data=df)
        ax2.set_title('Распределение значений и диаграмма размаха')
        ax2.set_xlabel('Значения')
        ax2.set_ylabel('Частота')
        

        plt.show()
        
        #основные характеристики
        display('Основные характеристики столбца', df[col].describe())
    except:
        display('Ошибка работы функции')
In [6]:
#функция для вычисления первого и второго квартиля и межквартильного размаха
def q_iqr(series):
    try:
        #первый квартиль
        q1 = np.percentile(
        series,
        25)

        #третий квартиль
        q3 = np.percentile(
        series,
        75)

        #межквартильный размах
        iqr = q3 - q1       
        
        #нижняя граница выбросов
        lower_fringe = q1 - iqr * 1.5 
        
        #верхняя граница выбросов
        upper_fringe = q3 + iqr * 1.5 

        return [q1, q3, iqr, lower_fringe, upper_fringe]
    except:
        return 'Ошибка работы функции'        
In [7]:
#функция для сегментации по непрерывному количественному признаку
def segmentation(dict, orig_df, col, col_name, total_rate):
    '''
    Функция принимает словарь с границами сегментов, исходный датафрейм, 
    название столбца исходного датафрейма, название признака, для которого 
    указаны границы сегментов и процент оттока для всех клиентов.
    Возвращает датафрейм с границами сегментов, их размерами 
    и отношением оттока сегментов к общему оттоку всех клиентов
    '''
    try:
        #создание датафрейма с границами сегментов
        segments_df = pd.DataFrame(
            dict
        ).T
        
        #создание набора строк с характеристиками сегмента в виде списка
        segments_df.columns = ['min','max']

        list=[]

        for seg in segments_df.index:
            min = segments_df.loc[seg]['min']
            max = segments_df.loc[seg]['max']
            list.append([    
                orig_df[(orig_df[col] >= min) & \
                        (orig_df[col] <= max)]['churn'].mean() * 100,
                (orig_df[(orig_df[col] >= min) & \
                        (orig_df[col] <= max)]['churn'].mean() * 100 / total_rate - 1) * 100,
                orig_df[(df[col] >= min) & \
                        (orig_df[col] <= max)]['churn'].count(),
            ])
        
        #добавление списка строк к датафрейму с границами сегментов
        segments_df[[
            'churn_rate', 
            'churn_to_total_churn_diff_%', 
            'segment_size']] = list
        
        #приведение данных к удобному виду
        segments_df[
            'churn_rate'
        ] = segments_df[
            'churn_rate'
        ].map('{:.2f}'.format).astype('float')
        segments_df[
            'churn_to_total_churn_diff_%'
        ] = segments_df[
            'churn_to_total_churn_diff_%'
        ].map('{:.2f}'.format).astype('float')
        
        segments_df['segment_size'] = segments_df['segment_size'].astype('int')
        
        segments_df[['min','max']] = segments_df[['min','max']].astype('str')

        #добавление столбца с текстовым описанием границ сегмента
        index = []
        
        for ind in segments_df.index:
            index.append(
                col + '_' + segments_df.loc[ind,'min'] + '-' + segments_df.loc[ind,'max']
            )
            
        segments_df['segment']=index
        
        #удаление стобцов со значениями границ сегмента
        segments_df = (segments_df
                   .drop(columns=['min','max'])
                   .sort_values(
                        by='churn_rate', 
                        ascending=False
                   )
            )

        

        #построение столбчатой диаграммы
        plt.figure(figsize=(15,segments_df.shape[0]))
        ax = sns.barplot(
            segments_df,
            x='churn_rate',
            y='segment',
            orient='y',
            order=segments_df.sort_values(
                by='churn_rate', 
                ascending=False
            )['segment']
        )
        plt.axvline(
            total_rate, 
            linestyle='--', 
            color='red',
            label='Общий процент оттока'
        )
        plt.legend(loc='upper center')
        ax.bar_label(ax.containers[0], fontsize=12)
        ax.set_title(f'Процент оттока по: {col}')
        ax.set_xlabel('Процент оттока')
        ax.set_ylabel(f'Признак: {col}');
        
        #сохранение изображения
        plt_name = pics_path + 'Процент оттока по признаку ' + col_name + '.png'        
        plt.savefig(
            plt_name,
            bbox_inches= 'tight'
        )
        plt.show();
        
        #функция возвращает датафрейм с полученной таблицей
        return segments_df
    except:
        display('Ошибка работы функции')

Загрузка и предобработка данных¶

Загрузка данных и основная информация¶

In [243]:
#импорт необходимых библиотек и установка инструментов

!pip install missingno
import missingno as msno

!pip install openpyxl

import pandas as pd

import matplotlib.pyplot as plt

import seaborn as sns

import numpy as np

from scipy import stats as st

import plotly.express as px

!pip install phik

import phik

#настройка ширины отображаемых колонок
pd.set_option('max_colwidth', 120)

#путь к файлу с данными
data_path = ''

#путь для сохранения изображений для презентации
pics_path = 'pics_banks_churn_analysis\\'

#выбор цветового стиля и палитры по умолчанию
plt.style.use('ggplot')
sns.set_palette('tab20') 

#отключение текстовых предупреждений
import warnings
warnings.filterwarnings("ignore")
Requirement already satisfied: missingno in c:\programdata\anaconda3\lib\site-packages (0.5.2)
Requirement already satisfied: numpy in c:\programdata\anaconda3\lib\site-packages (from missingno) (1.26.4)
Requirement already satisfied: matplotlib in c:\programdata\anaconda3\lib\site-packages (from missingno) (3.8.3)
Requirement already satisfied: scipy in c:\programdata\anaconda3\lib\site-packages (from missingno) (1.13.0)
Requirement already satisfied: seaborn in c:\programdata\anaconda3\lib\site-packages (from missingno) (0.13.2)
Requirement already satisfied: contourpy>=1.0.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (1.2.0)
Requirement already satisfied: cycler>=0.10 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (0.12.1)
Requirement already satisfied: fonttools>=4.22.0 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (4.50.0)
Requirement already satisfied: kiwisolver>=1.3.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (1.4.5)
Requirement already satisfied: packaging>=20.0 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (23.1)
Requirement already satisfied: pillow>=8 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (10.2.0)
Requirement already satisfied: pyparsing>=2.3.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (3.1.2)
Requirement already satisfied: python-dateutil>=2.7 in c:\programdata\anaconda3\lib\site-packages (from matplotlib->missingno) (2.8.2)
Requirement already satisfied: pandas>=1.2 in c:\programdata\anaconda3\lib\site-packages (from seaborn->missingno) (2.1.4)
Requirement already satisfied: pytz>=2020.1 in c:\programdata\anaconda3\lib\site-packages (from pandas>=1.2->seaborn->missingno) (2023.3.post1)
Requirement already satisfied: tzdata>=2022.1 in c:\programdata\anaconda3\lib\site-packages (from pandas>=1.2->seaborn->missingno) (2023.3)
Requirement already satisfied: six>=1.5 in c:\programdata\anaconda3\lib\site-packages (from python-dateutil>=2.7->matplotlib->missingno) (1.16.0)
Requirement already satisfied: openpyxl in c:\programdata\anaconda3\lib\site-packages (3.1.2)
Requirement already satisfied: et-xmlfile in c:\programdata\anaconda3\lib\site-packages (from openpyxl) (1.1.0)
Requirement already satisfied: phik in c:\programdata\anaconda3\lib\site-packages (0.12.4)
Requirement already satisfied: numpy>=1.18.0 in c:\programdata\anaconda3\lib\site-packages (from phik) (1.26.4)
Requirement already satisfied: scipy>=1.5.2 in c:\programdata\anaconda3\lib\site-packages (from phik) (1.13.0)
Requirement already satisfied: pandas>=0.25.1 in c:\programdata\anaconda3\lib\site-packages (from phik) (2.1.4)
Requirement already satisfied: matplotlib>=2.2.3 in c:\programdata\anaconda3\lib\site-packages (from phik) (3.8.3)
Requirement already satisfied: joblib>=0.14.1 in c:\programdata\anaconda3\lib\site-packages (from phik) (1.4.2)
Requirement already satisfied: contourpy>=1.0.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (1.2.0)
Requirement already satisfied: cycler>=0.10 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (0.12.1)
Requirement already satisfied: fonttools>=4.22.0 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (4.50.0)
Requirement already satisfied: kiwisolver>=1.3.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (1.4.5)
Requirement already satisfied: packaging>=20.0 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (23.1)
Requirement already satisfied: pillow>=8 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (10.2.0)
Requirement already satisfied: pyparsing>=2.3.1 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (3.1.2)
Requirement already satisfied: python-dateutil>=2.7 in c:\programdata\anaconda3\lib\site-packages (from matplotlib>=2.2.3->phik) (2.8.2)
Requirement already satisfied: pytz>=2020.1 in c:\programdata\anaconda3\lib\site-packages (from pandas>=0.25.1->phik) (2023.3.post1)
Requirement already satisfied: tzdata>=2022.1 in c:\programdata\anaconda3\lib\site-packages (from pandas>=0.25.1->phik) (2023.3)
Requirement already satisfied: six>=1.5 in c:\programdata\anaconda3\lib\site-packages (from python-dateutil>=2.7->matplotlib>=2.2.3->phik) (1.16.0)
In [12]:
#загрузка данных
df = pd.read_csv(data_path + 'bank_scrooge.csv')

#копия датафрейма до предобработки
df_copy = df.copy()

#основная информация о датафрейме
df.info()

#вывод первых строк датафрейма
df.head(20)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10000 entries, 0 to 9999
Data columns (total 12 columns):
 #   Column         Non-Null Count  Dtype  
---  ------         --------------  -----  
 0   USERID         10000 non-null  int64  
 1   score          10000 non-null  float64
 2   city           10000 non-null  object 
 3   gender         10000 non-null  object 
 4   age            9974 non-null   float64
 5   equity         10000 non-null  int64  
 6   balance        7705 non-null   float64
 7   products       10000 non-null  int64  
 8   credit_card    10000 non-null  int64  
 9   last_activity  10000 non-null  int64  
 10  EST_SALARY     10000 non-null  float64
 11  churn          10000 non-null  int64  
dtypes: float64(4), int64(6), object(2)
memory usage: 937.6+ KB
Out[12]:
USERID score city gender age equity balance products credit_card last_activity EST_SALARY churn
0 183012 850.0 Рыбинск Ж 25.0 1 59214.82 2 0 1 75719.14 1
1 146556 861.0 Рыбинск Ж 37.0 5 850594.33 3 1 0 86621.77 0
2 120722 892.0 Рыбинск Ж 30.0 0 NaN 1 1 1 107683.34 0
3 225363 866.0 Ярославль Ж 51.0 5 1524746.26 2 0 1 174423.53 1
4 157978 730.0 Ярославль М 34.0 5 174.00 1 1 0 67353.16 1
5 202305 856.0 Рыбинск М 56.0 4 863687.24 3 1 0 156619.80 0
6 177259 807.0 Ярославль Ж 39.0 3 405042.44 3 0 1 103838.32 0
7 218868 825.0 Ярославль Ж 38.0 4 458145.40 2 1 1 68085.48 0
8 211686 923.0 Ярославль М 54.0 5 1206337.87 2 1 0 155371.79 0
9 133130 906.0 Ярославль Ж 67.0 0 NaN 1 0 1 238055.53 0
10 148929 927.0 Ростов М 52.0 0 NaN 1 1 1 196820.07 0
11 172184 921.0 Ростов М 41.0 0 NaN 1 1 1 217469.48 0
12 132970 845.0 Ярославль Ж 31.0 3 591928.52 2 1 1 112697.16 1
13 166357 837.0 Рыбинск Ж 28.0 4 302249.22 2 0 1 30950.94 0
14 172138 815.0 Ярославль М 35.0 5 547499.87 2 1 1 105883.26 0
15 120260 731.0 Рыбинск М 42.0 3 1480548.47 3 1 0 160974.43 0
16 123335 829.0 Ярославль М 45.0 5 507842.84 1 1 1 169330.64 0
17 214277 793.0 Ярославль М 53.0 3 627034.50 2 1 0 183430.19 0
18 136651 876.0 Ярославль М 40.0 5 2307947.36 2 0 0 287010.61 0
19 127034 922.0 Рыбинск Ж 53.0 0 NaN 1 0 0 147094.82 0

В датасете 10000 строк, данные отображаются корректно.

Названия столбцов нужно привести к snake_case.Названия столбцов нужно привести к snake_case.

Для оптимизации обработки переменных необходимо преобразовать тип данных в int в столбцах, которые должны содержать только целочисленные значения:

  • score
  • age

В датасете есть пропуски в столбцах age и balance

Переименование столбцов.¶

In [16]:
df.columns = df.columns.str.lower()

df.columns
Out[16]:
Index(['userid', 'score', 'city', 'gender', 'age', 'equity', 'balance',
       'products', 'credit_card', 'last_activity', 'est_salary', 'churn'],
      dtype='object')

Обработка пропусков.¶

In [18]:
#диаграмма пропущенных значений
ax = msno.bar(df, figsize=(15,7))
ax.set_title(
    'Соотношение корректных и пропущенных значений в столбцах\n', 
    fontsize=16
)
ax.set_xlabel('Столбцы', fontsize=16)
ax.set_ylabel('Значения', fontsize=16)
plt.show();
No description has been provided for this image
In [19]:
#вывод количества пропущенных значений для каждого столбца
nans = df.isna().sum().to_frame()
nans.insert(
    loc=1,
    column='rate_%',
    value= round(df.isna().sum() *100 / len(df.index), 2)
)

nans
Out[19]:
0 rate_%
userid 0 0.00
score 0 0.00
city 0 0.00
gender 0 0.00
age 26 0.26
equity 0 0.00
balance 2295 22.95
products 0 0.00
credit_card 0 0.00
last_activity 0 0.00
est_salary 0 0.00
churn 0 0.00

Столбец age.¶

Пропусков в столбце 26 (0.26%). Заполнить данные по этому столбцу не представляется возможным - для этого нужно знать действительный возраст клиентов, заполнение синтетическими данными здесь не подойдёт. Для анализа и классификации пропусков их количество слишком мало.

Учитывая, что пропусков очень мало, целесообразно их просто удалить.

In [22]:
df = df.dropna(subset=['age'])

Столбец balance.¶

Пропусков в столбце 2293 (22.95%).

Изучим зависимость пропусков в столбце от имеющихся данных.

Кодирование пропусков и построение сводной таблицы зависимости.

In [26]:
#кодирование пропусков в столбце
df['isnan'] = df['balance'].isna().map(lambda x: 1 if x else 0)
In [27]:
#построение сводной таблицы мер центральной тенденции
pivot_nan = df.groupby('isnan')[[
            'score', 
            'city', 
            'gender', 
            'equity', 
            'age',
            'products', 
            'credit_card', 
            'last_activity', 
            'est_salary', 
            'churn'
        ]].agg(
            {
                'score':['mean','median'],
                'city':pd.Series.mode,
                'gender':pd.Series.mode,
                'equity':pd.Series.mode, 
                'age':'median',
                'products':'median', 
                'credit_card':pd.Series.mode, 
                'last_activity':pd.Series.mode, 
                'est_salary':['mean', 'median'], 
                'churn':pd.Series.mode
            }
        )


#транспонирование таблицы
pivot_nan = pivot_nan.T

#переименование столбцов
pivot_nan.columns = ['value','nan']

#таблица для количественных переменных
pivot_nan_quant = pivot_nan.loc[['score','est_salary','age'],:]

pivot_nan_quant['difference_%'] = (
    (pivot_nan_quant['nan'] - pivot_nan_quant['value']) / pivot_nan_quant['value']* 100
)

display(pivot_nan)
pivot_nan_quant
value nan
score mean 843.74412 865.357174
median 840.0 903.0
city mode Ярославль Ярославль
gender mode Ж М
equity mode 5 0
age median 40.0 40.0
products median 2.0 1.0
credit_card mode 1 1
last_activity mode 1 1
est_salary mean 124520.394264 226345.417464
median 106250.18 174347.87
churn mode 0 0
Out[27]:
value nan difference_%
score mean 843.74412 865.357174 2.561565
median 840.0 903.0 7.5
est_salary mean 124520.394264 226345.417464 81.773772
median 106250.18 174347.87 64.091835
age median 40.0 40.0 0.0

Значения мер различаются для пропусков и заполненных значений в столбцах gender, equity, products, score и est_salary.

Различие мер в столбце score небольшое - всего 7,5% для медианы и 2,56% для среднего, зависимости пропусков от значений столбца здесь нет.

В столбце est_salary медианное и среднее значения отличаются довольно сильно - на 81,77% b 64.09%.

Проведём тест независимости наличия пропусков и пола (gender) с помощью критерия V Крамера.

In [29]:
#таблица сопряжённости
tab = pd.crosstab(index=df['gender'], columns=df['isnan'])

#значение критерия
result = st.chi2_contingency(tab)

#значение V Крамера
V = np.sqrt ((result[0]/len(df)) / (min(tab.shape) - 1))

V
Out[29]:
0.0626051485739025

Критерий показывает наличие очень слабой взаимосвязи, ей можно пренебречь.

Далее, подсчитаем коэффициент корреляции Пирсона для тех из содержащих заметную разницу столбцов, которые содержат непрерывные, дискретные и упорядоченные категориальные переменные.

In [31]:
#построение тепловой карты 
plt.figure(figsize=(7,7))

ax = sns.heatmap(
    (df[[
        'isnan',
        'equity',
        'products', 
        'score',
        'est_salary'
        ]].corr()['isnan']
          .drop('isnan')
          .sort_values(ascending=False)
          .to_frame()), 
    annot=True, 
    cmap='coolwarm', 
    fmt='.2f',
    center=0
);

ax.set_ylabel('Коэффициент корелляции со столбцами')
ax.set_xlabel('Индикатор пропусков в столбце "balance"')
ax.set_title('Коэффициент корреляции Пирсона для пропусков в столбце "balance" и числовых переменных')

plt.show();
No description has been provided for this image

По шкале Чеддока корреляция достаточно сильная отрицательная корреляция только со столбцом equity, также корреляция со столбцом products на границе слабой и средней; пропуски нельзя отнести к категории MCAR.

Изучим процент пропусков в разбивке по значениям этих столбцов.

Также нужно учитывать, что значения столбца equity - не дискретная переменная, а упорядоченная категориальная.

In [34]:
#процент пропусков для значений столбца "equity"
pivot = df.pivot_table(
    index='equity', 
    values='userid', 
    columns='isnan', 
    aggfunc='count'
).fillna(0)
pivot['total'] = pivot.sum(axis=1)
pivot['rate_%'] = round(pivot[1] * 100 / pivot['total'], 2)
pivot['nan_%'] = round(pivot[1] / pivot[1].sum(axis=0), 2)
pivot
Out[34]:
isnan 0 1 total rate_% nan_%
equity
0 412.0 2164.0 2576.0 84.01 0.95
1 659.0 114.0 773.0 14.75 0.05
2 1051.0 0.0 1051.0 0.00 0.00
3 1540.0 1.0 1541.0 0.06 0.00
4 1847.0 0.0 1847.0 0.00 0.00
5 1915.0 0.0 1915.0 0.00 0.00
6 161.0 0.0 161.0 0.00 0.00
7 80.0 0.0 80.0 0.00 0.00
8 17.0 0.0 17.0 0.00 0.00
9 13.0 0.0 13.0 0.00 0.00
In [35]:
##процент пропусков для значений столбца "products"
pivot = df.pivot_table(
    index='products', 
    values='userid', 
    columns='isnan', 
    aggfunc='count'
).fillna(0)
pivot['total'] = pivot.sum(axis=1)
pivot['rate_%'] = round(pivot[1] * 100 / pivot['total'], 2)
pivot['nan_%'] = round(pivot[1] / pivot[1].sum(axis=0), 2)

pivot
Out[35]:
isnan 0 1 total rate_% nan_%
products
0 0.0 1.0 1.0 100.00 0.00
1 1389.0 1934.0 3323.0 58.20 0.85
2 4804.0 315.0 5119.0 6.15 0.14
3 1009.0 29.0 1038.0 2.79 0.01
4 474.0 0.0 474.0 0.00 0.00
5 19.0 0.0 19.0 0.00 0.00

Дополнительно подсчитаем процент пропусков для Наблюдений с equity = 0 и products = 1.

In [37]:
round(
    df[
        (df['equity'] == 0) & (df['products'] == 1)
    ]['balance'].isna().sum() / df['balance'].isna().sum() * 100, 
    2
)
Out[37]:
80.56

Налицо две явные зависимости между количеством пропусков и значениями столбцов:

  • чем ниже балл собственности, тем выше процент пропусков; почти все пропуски сосредоточены в категориях "0" и "1", в категории "0" расположены 95% всех пропусков;
  • чем меньше у клиента продуктов, тем выше процент пропусков; почти все пропуски сосредоточены в категориях "1" и "2", в категории "1" расположены 85% всех пропусков;

Можно сделать вывод, что наличие пропусков в столбце balance зависит от имеющихся данных, что позволяет отнести их к категории MAR.

80,56% пропусков относятся к группе клиентов с баллом собственности 0 и количеством продуктов 1. Возможно, речь идёт о группе малоимущих клиентов либо клиентов, которые мало пользуются продуктами банка - причину пропусков можно попробовать выяснить у заказчика, в любом случае на это стоит обратить его внимание.

Пропусков в этой группе слишком много, чтобы их удалять или заполнять синтетическими данными, но можно выделить эту группу и при анализе по столбцу balance рассматривать её отдельно от других, имея в виду недостаток наблюдений по этому набору признаков.

Пропуски для групп клиентов с количеством продуктов более 1 и баллом собственности более 1 можно удалить ввиду их малого количества.

In [42]:
#удаление
df = df.drop(df[
    ~((df['equity'] == 0) & (df['products'] == 1)) & df['balance'].isna()
].index)

#переменная с индексами группы клиентов, содержащей большой процент пропусков
nan_clients = df.query('(equity == 0) & (products == 1)').index

#удаление вспомогательного столбца 
df = df.drop(columns='isnan')

Количество пропусков после их обработки.

In [44]:
#диаграмма пропущенных значений
ax = msno.bar(df, figsize=(15,7))
ax.set_title(
    'Соотношение корректных и пропущенных значений в столбцах\n', 
    fontsize=16
)
ax.set_xlabel('Столбцы', fontsize=16)
ax.set_ylabel('Значения', fontsize=16)
plt.show();
No description has been provided for this image

Обработка дубликатов¶

Явные дубликаты.

In [46]:
df.duplicated().sum()
Out[46]:
0

Неявные дубликаты могут присутствовать в столбце userid.

In [48]:
df['userid'].duplicated().sum()
Out[48]:
44

44 дубликата. Изучим эти строки более подробно.

In [50]:
id_dups = df[df['userid'].duplicated(keep=False)].sort_values(by='userid')
pd.set_option('display.max_rows', 88)
id_dups
Out[50]:
userid score city gender age equity balance products credit_card last_activity est_salary churn
7694 116540 887.0 Ярославль Ж 38.0 0 NaN 1 0 1 119247.61 0
1893 116540 883.0 Рыбинск Ж 55.0 1 362756.49 3 0 1 175920.48 1
7542 117943 880.0 Ярославль Ж 40.0 0 NaN 1 1 0 137718.93 0
4866 117943 855.0 Рыбинск Ж 32.0 6 1036832.93 4 1 1 107792.71 1
5863 120258 908.0 Рыбинск Ж 38.0 4 2213581.63 2 0 1 160327.77 1
5896 120258 905.0 Ярославль М 30.0 0 NaN 1 1 1 146427.96 0
9775 124450 758.0 Рыбинск Ж 36.0 0 73574.07 2 0 1 79963.59 0
8164 124450 859.0 Ярославль Ж 38.0 6 516811.20 1 1 1 95144.63 1
5420 126368 832.0 Рыбинск Ж 33.0 0 NaN 1 0 0 118465.34 0
4850 126368 909.0 Ярославль Ж 30.0 4 1322623.29 2 0 1 140675.66 1
9504 129785 949.0 Ярославль М 43.0 0 NaN 1 1 1 166845.37 0
3988 129785 904.0 Рыбинск М 33.0 5 2427737.60 3 0 1 188856.75 1
2138 131419 739.0 Рыбинск Ж 37.0 3 1307941.65 3 0 0 103867.47 0
904 131419 934.0 Ярославль М 35.0 5 2348335.95 2 0 0 217105.13 1
1608 140377 802.0 Ярославль М 26.0 3 539863.29 4 1 1 112167.83 1
7487 140377 930.0 Рыбинск Ж 34.0 5 1123967.02 1 1 1 148336.26 0
2276 141265 858.0 Ярославль М 37.0 4 471599.16 2 1 1 148806.88 1
5569 141265 811.0 Рыбинск Ж 33.0 3 1044316.88 2 1 0 46055.32 0
5369 143592 885.0 Рыбинск Ж 56.0 4 1201087.46 2 1 1 88223.89 1
5557 143592 764.0 Ярославль Ж 68.0 3 508919.15 2 0 1 71211.66 0
8272 149365 793.0 Рыбинск Ж 61.0 0 476496.99 2 1 1 46147.06 0
946 149365 877.0 Ярославль М 41.0 5 819182.01 2 0 0 212316.47 1
9267 150667 931.0 Ярославль М 41.0 0 NaN 1 0 0 261904.44 0
2439 150667 813.0 Рыбинск Ж 44.0 0 62504.47 4 1 0 152393.79 1
690 152479 768.0 Рыбинск Ж 42.0 0 126787.79 4 1 1 143233.23 1
5705 152479 723.0 Ярославль Ж 22.0 0 NaN 1 1 0 83955.65 0
3274 155765 863.0 Ярославль М 30.0 5 1036114.50 5 1 1 150744.50 1
5197 155765 923.0 Рыбинск М 30.0 0 NaN 1 1 1 120296.60 0
1080 155872 896.0 Ярославль М 36.0 3 1113656.92 3 0 1 291176.85 1
9363 155872 714.0 Рыбинск Ж 29.0 0 NaN 1 1 0 118052.84 0
9508 160075 824.0 Рыбинск Ж 57.0 3 355047.33 2 0 0 64351.81 0
3936 160075 899.0 Ярославль М 34.0 4 1647814.67 4 1 0 151888.42 1
5629 162053 921.0 Рыбинск М 33.0 3 857011.13 2 1 1 113512.57 1
8733 162053 786.0 Ярославль М 29.0 4 205824.97 3 1 0 121371.73 0
3564 163207 853.0 Рыбинск М 42.0 4 543839.62 1 1 1 105281.97 1
6786 163207 838.0 Ярославль Ж 42.0 4 652776.60 2 1 1 97545.36 0
7271 164676 790.0 Рыбинск Ж 77.0 1 111202.01 1 1 0 32835.45 0
58 164676 860.0 Ярославль М 37.0 5 648594.03 2 1 1 213586.86 1
9785 168998 895.0 Рыбинск М 44.0 0 282653.53 3 0 0 255296.47 0
5020 168998 886.0 Ярославль Ж 35.0 4 394780.61 2 1 1 510577.10 1
3039 170312 813.0 Рыбинск Ж 47.0 3 370027.67 2 0 0 129835.56 0
790 170312 901.0 Ярославль М 33.0 4 3276783.93 3 0 1 358700.96 1
4587 171751 874.0 Рыбинск Ж 26.0 5 292054.42 2 0 1 20528.56 1
9653 171751 862.0 Ярославль М 42.0 4 481992.19 2 1 0 71816.86 0
2941 172142 896.0 Ярославль Ж 30.0 4 1009073.14 4 1 0 70866.29 1
6756 172142 751.0 Рыбинск Ж 69.0 3 396568.03 2 0 1 45608.45 0
9970 175730 816.0 Рыбинск М 36.0 4 477892.07 3 1 0 81100.60 0
7753 175730 846.0 Ярославль Ж 32.0 7 216764.74 4 1 1 77100.85 1
8705 181526 895.0 Рыбинск М 29.0 0 NaN 1 1 1 124786.53 0
2989 181526 918.0 Ярославль Ж 37.0 5 1366382.35 4 1 1 97932.95 1
9146 183510 800.0 Рыбинск Ж 42.0 7 473293.81 2 1 0 54049.88 0
8584 183510 922.0 Ярославль М 26.0 5 448542.42 3 0 1 66063.13 1
150 185748 850.0 Рыбинск М 36.0 5 1203376.67 2 0 1 89401.82 1
3106 185748 912.0 Ярославль Ж 47.0 5 1598218.98 2 0 0 180256.98 0
5454 188957 853.0 Ярославль М 35.0 1 374959.62 2 1 1 197600.97 1
6005 188957 882.0 Рыбинск Ж 64.0 3 893392.91 3 1 0 69622.58 0
1583 190253 726.0 Ярославль М 49.0 0 NaN 1 1 1 177700.78 0
231 190253 823.0 Рыбинск М 37.0 4 373348.39 2 0 1 131947.92 1
3859 191520 773.0 Рыбинск М 36.0 3 1219918.08 4 1 1 119933.13 1
7931 191520 767.0 Ярославль М 45.0 0 NaN 1 1 1 880005.05 0
9659 195884 767.0 Рыбинск Ж 45.0 1 335652.56 3 1 1 100197.67 0
2226 195884 906.0 Ярославль М 49.0 6 1354552.83 2 0 0 270678.42 1
9035 200863 933.0 Рыбинск М 29.0 7 766924.56 2 0 1 89323.33 1
9175 200863 832.0 Ярославль М 42.0 5 620064.73 2 0 0 188054.46 0
1513 208081 873.0 Ярославль М 31.0 2 232814.75 2 1 1 122505.86 1
2998 208081 821.0 Рыбинск Ж 62.0 3 493608.26 2 1 1 33491.45 0
5840 208738 839.0 Ярославль М 60.0 5 1102438.37 2 0 1 116500.81 0
1580 208738 897.0 Рыбинск Ж 45.0 5 859018.19 2 1 1 145510.08 1
5570 208815 773.0 Ярославль М 39.0 4 288110.09 2 1 0 56971.91 0
3350 208815 877.0 Рыбинск М 25.0 4 239421.18 1 1 1 48638.00 1
7453 210627 864.0 Ярославль Ж 38.0 4 1527225.32 4 1 1 162999.17 1
8521 210627 804.0 Рыбинск М 30.0 2 347818.88 3 1 0 129015.25 0
4986 210792 884.0 Рыбинск М 48.0 4 2228037.20 2 0 1 207978.09 1
7814 210792 839.0 Ярославль М 42.0 2 662646.97 1 1 0 138403.60 0
9880 217412 886.0 Ярославль Ж 35.0 3 1026172.14 2 1 0 53099.16 0
7055 217412 879.0 Рыбинск М 42.0 5 568140.15 3 0 0 231658.67 1
4611 217619 903.0 Рыбинск Ж 59.0 5 844055.83 2 0 1 133999.76 1
5807 217619 766.0 Ярославль М 52.0 2 218199.47 1 1 0 120356.44 0
4186 217643 804.0 Рыбинск Ж 40.0 2 301836.65 2 1 1 81137.22 0
1970 217643 845.0 Ярославль М 29.0 5 733098.45 4 1 1 41443.01 1
9406 217826 790.0 Рыбинск Ж 46.0 4 430414.80 2 1 1 49400.37 0
4623 217826 842.0 Ярославль М 39.0 4 439747.65 2 0 1 116736.75 1
2302 220816 934.0 Рыбинск Ж 74.0 5 1294285.39 3 1 0 95606.80 0
1740 220816 840.0 Ярославль Ж 41.0 3 1193287.13 2 1 1 76434.94 1
4216 226719 903.0 Рыбинск Ж 63.0 0 NaN 1 1 0 138582.58 0
2597 226719 990.0 Ярославль М 37.0 4 14648692.14 2 0 0 934412.61 1
8497 227795 839.0 Ярославль М 34.0 2 326593.14 2 1 0 103314.92 0
8205 227795 840.0 Рыбинск М 34.0 2 350768.03 1 1 0 102036.14 1

Бросается в глаза, что дубликаты образуют пары с двумя разными значениями в столбце city. Проверим это.

In [52]:
pd.set_option('display.max_rows', 10)

#количество пар id, у которых различные значения столбца "city"
display(sum(id_dups.groupby('userid')['city'].nunique() > 1))

#названия городов
id_dups['city'].unique()
44
Out[52]:
array(['Ярославль', 'Рыбинск'], dtype=object)

Так и есть: во всех парах с дублирующимся userid различаются города - Ярославль и Рыбинск.

Возможные причины появления таких дубликатов:

  1. Разным клиентам в разных городах по ошибке присвоены одинаковые userid (несовершенство системы сбора данных при работе с ней нескольких филиалов). Такие строки удалять не стоит, в них содержится информация о разных клиентах. Но в этом случае при дальнейшем анализе нужно будет учитывать, что userid в датасете могут быть не уникальны.
  2. Один и тот же клиент попал в базу дважды, в разных городах - например, не был удалён из базы при смене обслуживающего филиала, либо был дважды внесён в базу по ошибке. Такие дубликаты нужно удалить. В этом случае в строках будут дублироваться не только userid, но и пол, и баллы кредитного скоринга. Это можно проверить.
In [54]:
id_dups[id_dups[['userid','gender','score']].duplicated(keep=False)]
Out[54]:
userid score city gender age equity balance products credit_card last_activity est_salary churn

Таких клиентов нет.

Вывод: клиенты с дублирующимися userid - это разные клиенты из разных городов, которым по ошибке присвоены одинаковые userid (несовершенство системы сбора данных при работе с ней нескольких филиалов). Такие строки удалять не стоит, в них содержится информация о разных клиентах. Но в этом случае при дальнейшем анализе нужно будет учитывать, что userid в датасете могут быть не уникальны, однако доля таких клиентов очень мала (менее 0,5%).

Преобразование типов данных¶

In [58]:
df[['age','score']] = df[['age','score']].astype(int)

Кодирование категориальных переменных¶

In [60]:
df[['city_','gender_']] = df[['city','gender']]
df = pd.get_dummies(df, columns=['city_','gender_'], dtype='int')
df.head()
Out[60]:
userid score city gender age equity balance products credit_card last_activity est_salary churn city__Ростов city__Рыбинск city__Ярославль gender__Ж gender__М
0 183012 850 Рыбинск Ж 25 1 59214.82 2 0 1 75719.14 1 0 1 0 1 0
1 146556 861 Рыбинск Ж 37 5 850594.33 3 1 0 86621.77 0 0 1 0 1 0
2 120722 892 Рыбинск Ж 30 0 NaN 1 1 1 107683.34 0 0 1 0 1 0
3 225363 866 Ярославль Ж 51 5 1524746.26 2 0 1 174423.53 1 0 0 1 1 0
4 157978 730 Ярославль М 34 5 174.00 1 1 0 67353.16 1 0 0 1 0 1

Проверка значения столбцов на предмет явных ошибок и аномалий.¶

In [63]:
for col in ['equity',
            'products',
            'credit_card',
            'last_activity',
            'churn']:
    display(f'Значения столбца {col}:',df[col].value_counts(),'')
'Значения столбца equity:'
equity
0    2248
5    1915
4    1847
3    1540
2    1051
1     659
6     161
7      80
8      17
9      13
Name: count, dtype: int64
''
'Значения столбца products:'
products
2    4804
1    3225
3    1009
4     474
5      19
Name: count, dtype: int64
''
'Значения столбца credit_card:'
credit_card
1    6427
0    3104
Name: count, dtype: int64
''
'Значения столбца last_activity:'
last_activity
1    4983
0    4548
Name: count, dtype: int64
''
'Значения столбца churn:'
churn
0    7717
1    1814
Name: count, dtype: int64
''

Явно ошибочных значений в столбцах нет.

Проверка распределения количественных переменных на наличие аномалий и выбросов.¶

Столбец balance¶

In [67]:
description(df,'balance')
No description has been provided for this image
'Основные характеристики столбца'
count    7.695000e+03
mean     8.272456e+05
std      1.980327e+06
min      0.000000e+00
25%      2.956987e+05
50%      5.242953e+05
75%      9.800512e+05
max      1.191136e+08
Name: balance, dtype: float64

Распределение включает выбросы из большого количества редких высоких значений и сильно скошено в меньшую сторону относительно нормального, что смещает среднее в сторону больших значений. Оценим отдельно распределение значений больше и меньше расчётной верхней границы выбросов.

Распределение основной массы значений.

In [69]:
#верхняя граница выбросов
lim = q_iqr(df['balance'].dropna())[4]

#гистограмма, диаграмма размаха и основные характеристики
description(df[df['balance'] < lim], 'balance')
No description has been provided for this image
'Основные характеристики столбца'
count    7.268000e+03
mean     6.137157e+05
std      4.308430e+05
min      0.000000e+00
25%      2.864130e+05
50%      4.885574e+05
75%      8.723392e+05
max      1.999799e+06
Name: balance, dtype: float64

Распределение основной массы значений баланса средств укладывается в границы от 0 до 20 млн, сильно скошено в меньшую сторону относительно нормального. Среднее значение смещено к большим значениям относительно медианного.

Распределение высоких значений.

In [72]:
#гистограмма, диаграмма размаха и основные характеристики
description(df[df['balance'] > lim], 'balance', bins=200)
No description has been provided for this image
'Основные характеристики столбца'
count    4.270000e+02
mean     4.461755e+06
std      7.324261e+06
min      2.009137e+06
25%      2.355243e+06
50%      2.917344e+06
75%      4.090911e+06
max      1.191136e+08
Name: balance, dtype: float64

Распределение аномально высоких значений находится в границах от 20 до 119 млн. Поскольку в столбце содержатся актуальные данные о балансе клиентов, можно предположить, что речь идёт об очень состоятельных клиентах, которые держат значительные средства в виде вкладов и других продуктах банка, т.е. данные значения не являются выбросами, а просто относятся к сегменту элитных клиентов банка. Удалять такие значения не стоит, но поскольку такие данные вносят искажают характеристики распределения, при анализе по данному столбцу лучше использовать медиану вместо среднего.

Столбец score¶

In [75]:
#гистограмма, диаграмма размаха и основные характеристики
description(df, 'score',bins=50)
No description has been provided for this image
'Основные характеристики столбца'
count    9531.000000
mean      848.029798
std        63.845105
min       642.000000
25%       802.000000
50%       851.000000
75%       897.000000
max      1000.000000
Name: score, dtype: float64

Распределение значений близко к нормальному, среднее и медиана почти не отличаются, стандартное отклонение небольшое. Ярко выраженного пика распределения нет, возможно, их несколько.

Выделяется группа клиентов с рейтингом около 650. Изучим их более внимательно.

In [77]:
#гистограмма, диаграмма размаха и основные характеристики
description(df[df['score'] < 675], 'score', bins=10)
No description has been provided for this image
'Основные характеристики столбца'
count     67.000000
mean     656.432836
std        6.895813
min      642.000000
25%      652.000000
50%      655.000000
75%      661.000000
max      673.000000
Name: score, dtype: float64

67 клиентов, чей балл кредитного скоринга распределён примерно нормально. Вероятно, этих клиентов можно выделить в отдельную группу и изучить на этапе исследовательского анализа.

Аномалий и выбросов в столбце нет.

Столбец age¶

In [80]:
#гистограмма, диаграмма размаха и основные характеристики
description(df, 'age', bins=df['age'].nunique())
No description has been provided for this image
'Основные характеристики столбца'
count    9531.000000
mean       42.748925
std        12.205301
min        18.000000
25%        33.000000
50%        40.000000
75%        51.000000
max        86.000000
Name: age, dtype: float64

Распределение близко к нормальному, скошено в меньшую сторону. Возраст клиентов в датасете от 18 до 86 лет. Ошибок и аномалий нет.

Столбец est_salary¶

In [83]:
#гистограмма, диаграмма размаха и основные характеристики
description(df, 'est_salary')
No description has been provided for this image
'Основные характеристики столбца'
count    9.531000e+03
mean     1.438294e+05
std      1.335287e+05
min      2.546300e+03
25%      7.410336e+04
50%      1.175064e+05
75%      1.713289e+05
max      1.395064e+06
Name: est_salary, dtype: float64

Распределение скошено в меньшую сторону и имеет аномально редкие высокие значения в диапазоне более 400000. Изучим эту часть датафрейма.

In [85]:
df[df['est_salary'] > 400000]
Out[85]:
userid score city gender age equity balance products credit_card last_activity est_salary churn city__Ростов city__Рыбинск city__Ярославль gender__Ж gender__М
44 210313 935 Ярославль М 41 5 3781118.22 2 1 0 761349.20 0 0 0 1 0 1
107 134358 985 Рыбинск Ж 41 4 6749773.07 2 1 0 698013.27 0 0 1 0 1 0
124 192966 686 Рыбинск Ж 33 0 NaN 1 1 1 518488.59 0 0 1 0 1 0
125 227092 1000 Ярославль М 32 5 19757180.85 2 1 1 1024626.50 0 0 0 1 0 1
137 163907 761 Ярославль М 31 0 NaN 1 1 0 999177.17 0 0 0 1 0 1
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9751 123478 693 Ярославль М 41 0 NaN 1 1 1 667513.52 0 0 0 1 0 1
9862 102186 913 Ярославль М 48 4 4203148.67 4 1 1 917628.11 0 0 0 1 0 1
9908 211089 993 Рыбинск М 38 6 4542980.70 1 1 1 491609.13 1 0 1 0 0 1
9964 148232 968 Ярославль Ж 32 0 NaN 1 1 0 520631.22 0 0 0 1 1 0
9984 125941 729 Ярославль Ж 42 0 NaN 1 1 1 687538.70 0 0 0 1 1 0

288 rows × 17 columns

Проверим, не отличаются ли эти выбросы процентом оттока.

In [87]:
display(
    'Средний процент оттока среди выбросов',
    round(df[df['est_salary'] > 400000]['churn'].mean() * 100, 2)
)
display(
    'Общий процент оттока',
    round(df['churn'].mean() * 100, 2)
)
'Средний процент оттока среди выбросов'
17.36
'Общий процент оттока'
19.03

Средний процент оттока аномальных клиентов отличается от общего слабо.

Поскольку в данном столбце расчётные данные, их ценность для анализа не так высока, как у эмпирических, к тому же высока вероятность, что такие выбросы содержат большую долю ошибок. Выбросов мало (3%), и их можно удалить.

In [90]:
#удаление
df = df.query('est_salary < 400000')

#гистограмма, диаграмма размаха и основные характеристики
description(df,'est_salary')
No description has been provided for this image
'Основные характеристики столбца'
count      9243.000000
mean     125054.571187
std       69766.262551
min        2546.300000
25%       72730.660000
50%      115073.100000
75%      165694.795000
max      399043.430000
Name: est_salary, dtype: float64

Распределение близко к нормальному, скошено в меньшую сторону, среднее достаточно близко к медианному, стандартное отклонение почти вдвое меньше, чем в наборе данных до фильтрации выбросов.

Вывод по разделу предобработки данных¶

Датафрейм после предобработки

In [96]:
#основная информация
df.info()

#процент удалённых пропусков
del_rate = (df_copy.shape[0] - df.shape[0]) * 100 / df_copy.shape[0]

display(f'Процент удалённых данных {del_rate:.2f}')
<class 'pandas.core.frame.DataFrame'>
Index: 9243 entries, 0 to 9999
Data columns (total 17 columns):
 #   Column           Non-Null Count  Dtype  
---  ------           --------------  -----  
 0   userid           9243 non-null   int64  
 1   score            9243 non-null   int32  
 2   city             9243 non-null   object 
 3   gender           9243 non-null   object 
 4   age              9243 non-null   int32  
 5   equity           9243 non-null   int64  
 6   balance          7576 non-null   float64
 7   products         9243 non-null   int64  
 8   credit_card      9243 non-null   int64  
 9   last_activity    9243 non-null   int64  
 10  est_salary       9243 non-null   float64
 11  churn            9243 non-null   int64  
 12  city__Ростов     9243 non-null   int32  
 13  city__Рыбинск    9243 non-null   int32  
 14  city__Ярославль  9243 non-null   int32  
 15  gender__Ж        9243 non-null   int32  
 16  gender__М        9243 non-null   int32  
dtypes: float64(2), int32(7), int64(6), object(2)
memory usage: 1.0+ MB
'Процент удалённых данных 7.57'

Данные загружены, названия столбцов приведены к стилю snake_case.

Пропуски в стобце age удалены. Пропуски в столбце balance классифицированы как случайные (MAR) и частично удалены, выделена группа клиентов с высоким содержанием пропусков (80,56%) с баллом собственности 0 и количеством продуктов 1. Возможно, речь идёт о группе малоимущих клиентов либо клиентов, которые мало пользуются продуктами банка - причину пропусков можно попробовать выяснить у заказчика, в любом случае на это стоит обратить его внимание.

Пропуски для групп клиентов с количеством продуктов более 1 и баллом собственности более 1 удалены ввиду их малого количества.

Явные дубликаты не обнаружены, неявные дубликаты изучены. Клиенты с дублирующимися userid - это, по-видимому, разные клиенты из разных городов (Ярославль и Рыбинск), которым по ошибке присвоены одинаковые userid (вероятно, несовершенство системы сбора данных при работе с ней нескольких филиалов). При дальнейшем анализе нужно учесть, что userid в датасете могут быть не уникальны.

Категориальные переменные стобцов city и gender закодированы.

Удалены выбросы в столбце est_salary.

В процессе предобработки удалены 7,57% данных.

Исследовательский анализ данных¶

Общий процент уходящих клиентов.¶

In [101]:
total_ch_rate = round(df['churn'].mean() * 100, 2)

total_ch_rate
Out[101]:
19.08

Общий процент уходящих клиентов в датсете 19,08%.

Портреты типичного остающегося и уходящего клиентов¶

Средние и медианные значения уходящих и остающихся клиентов по количественным признакам.¶

In [106]:
avg_quant = df.pivot_table(
    index='churn', 
    values=[
        'score',
        'age',
        'balance',
        'est_salary'
    ], 
    aggfunc={
        'score':['mean','median'],
        'age':'median',
        'balance':['mean','median'],
        'est_salary':['mean','median']
    }
).T

avg_quant.columns = ['nonchurn','churn']

avg_quant['total']= pd.Series(

    data=[
        df['age'].median(),
        df['balance'].mean(),
        df['balance'].median(),
        df['est_salary'].mean(),
        df['est_salary'].median(),
        df['score'].mean(),
        df['score'].median()
    ]
).values

avg_quant['churn_nonchurn_diff_%'] = \
(avg_quant['churn'] - avg_quant['nonchurn']) * 100 / avg_quant['nonchurn']

avg_quant['churn_total_diff_%'] = \
(avg_quant['churn'] - avg_quant['total']) * 100 / avg_quant['total']

avg_quant['nonchurn_total_diff_%'] = \
(avg_quant['nonchurn'] - avg_quant['total']) * 100 / avg_quant['total']


avg_quant = round(avg_quant, 1)#.map('{:.1f}'.format)

avg_quant
Out[106]:
nonchurn churn total churn_nonchurn_diff_% churn_total_diff_% nonchurn_total_diff_%
age median 40.0 39.0 40.0 -2.5 -2.5 0.0
balance mean 673232.1 1017219.3 753099.2 51.1 35.1 -10.6
median 471128.8 765990.0 515461.0 62.6 48.6 -8.6
est_salary mean 123337.1 132336.4 125054.6 7.3 5.8 -1.4
median 112637.8 122407.5 115073.1 8.7 6.4 -2.1
score mean 843.4 861.7 846.9 2.2 1.7 -0.4
median 844.0 866.0 849.0 2.6 2.0 -0.6

Сравнение типичных пользователей по категориальным признакам.¶

In [108]:
typical_categorial = df.groupby('churn')[[
    'city',
    'gender',
    'equity',
    'products',
    'credit_card',
    'last_activity']].agg(
    {
        'city':pd.Series.mode,
        'gender':pd.Series.mode,
        'equity':'median',
        'products':'median',
        'credit_card':'median',
        'last_activity':'median'
    }
).T

typical_categorial
Out[108]:
churn 0 1
city Ярославль Ярославль
gender Ж М
equity 3.0 4.0
products 2.0 2.0
credit_card 1.0 1.0
last_activity 0.0 1.0

Различия и сходства портретов типичных пользователей.¶

Типичный уходящий клиент:

  • медианный возраст 39 лет;
  • средний баланс на счёте 1017219,3, медианный 765990;
  • средний оценочный доход 132336,4, медианный 122407,5;
  • средний балл кредитного скоринга 861,7, медианный 849;
  • город - Ярославль;
  • пол мужской;
  • медианный балл собственности 5;
  • медианное количество продуктов 2;
  • есть кредитная карта;
  • проявлял активность в последнее время.

Типичный остающийся клиент:

  • медианный возраст 40 лет;
  • средний баланс на счёте 673232.1, медианный 471128.8;
  • средний оценочный доход 123337.1, медианный 112637.8;
  • средний балл кредитного скоринга 843.4, медианный 844.0;
  • город - Ярославль;
  • пол женский;
  • медианный балл собственности 3;
  • медианное количество продуктов 2;
  • есть кредитная карта;
  • не проявлял активность в последнее время.

Сходства в портретах типичного уходящего и остающегося клиентов:

  • наличие кредитной карты;
  • количество продуктов: 2.

Различия в портретах типичного уходящего и остающегося клиентов:

  • средний баланс уходящего клиента выше на 51,1% (медианный - на 62,6%);
  • среди уходящих клиентов больше мужчин, среди остающихся - женщин;
  • медианное количество продуктов у уходящих клиентов больше, чем у остающихся.
  • оценочный доход уходящего клиента выше на 7,3% (медианный - на 8,7%);

Остальные признаки различаются, но незначительно.

Анализ распределения непрерывных и дискретных переменных для остающихся и уходящих клиентов¶

Столбец balance¶

Для удобства представления рассмотрим отдельно клиентов с обычным и аномально большим балансом.

In [114]:
#граница совместного распределения клиентов с аномально большими доходами
lim=q_iqr(df.dropna()['balance'])[4]

#график клиентов с обычными доходами
fig = px.histogram(
    df[df['balance'] < lim],
    x='balance',
    width=1400,
    height=1000,
    color='churn',
    nbins=100,
    color_discrete_sequence=['steelblue','grey'],
    histnorm='probability density',
    barmode='overlay',
    labels={'churn':'Признак оттока'},
    title='Распределение значений баланса уходящих и остающихся клиентов с обычным балансом'
)

fig.update_xaxes(
    title_text='Значения'
)

fig.update_yaxes(
    title_text='Частота'
)

new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))

fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))

#сохранение изображения
plt_name = pics_path + 'Распределение значений баланса уходящих и остающихся клиентов с обычным балансом.png'        

fig.write_image(
    plt_name,
    width=1400, 
    height=1000
)

fig.show()
In [117]:
#график совместного распределения клиентов с обычными доходами
fig = px.histogram(
    df[df['balance'] > lim],
    x='balance',
    width=1400,
    height=1000,
    color='churn',
    nbins=100,
    color_discrete_sequence=['grey','steelblue'],
    histnorm='probability density',
    barmode='overlay',
    labels={'churn':'Признак оттока'},
    title='Распределение значений баланса уходящих и остающихся клиентов с высоким балансом'
)

fig.update_xaxes(
    title_text='Значения'
)

fig.update_yaxes(
    title_text='Частота'
)

new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))

fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))

#сохранение изображения
plt_name = pics_path + 'Распределение значений баланса уходящих и остающихся клиентов с высоким балансом.png'        

fig.write_image(
    plt_name,
    width=1400, 
    height=1000
)

fig.show('png')
No description has been provided for this image

Можно выделить следующие достаточно большие сегменты с повышенным оттоком клиентов:

  • клиенты с балансом 750000-870000;
  • клиенты с балансом 890000 - 1,07 млн;
  • клиенты с балансом 1,1 млн - 2 млн.

Оценим численность этих сегментов и превышение их оттока относительно среднего показателя.

In [120]:
#границы сегментов
balance_seg_borders = {
        'seg_a':[750000,870000], 
        'seg_b':[890000,1070000], 
        'seg_c':[1100000,2000000], 
    }

#датафрейм с агрегированной информацией по по столбцу
balance_segments = segmentation(balance_seg_borders, df, 'balance','баланс на счёте', total_ch_rate)        

balance_segments
No description has been provided for this image
Out[120]:
churn_rate churn_to_total_churn_diff_% segment_size segment
seg_c 38.02 99.28 1173 balance_1100000-2000000
seg_a 26.68 39.84 446 balance_750000-870000
seg_b 26.35 38.09 501 balance_890000-1070000

Клиенты с более высоким балансом более склонны к уходу. Представляют интерес клиенты с балансом 1,1-2 млн. Средний процент оттока у таких клиентов превышает общий показатель почти вдвое.

Столбец est_salary¶

In [123]:
#график совместного распределения 
fig = px.histogram(
    df,
    x='est_salary',
    width=1400,
    height=1000,
    color='churn',
    nbins=100,
    color_discrete_sequence=['steelblue','grey'],
    histnorm='probability density',
    barmode='overlay',
    labels={'churn':'Признак оттока'},
    title='Распределение значений дохода уходящих и остающихся клиентов'
)

fig.update_xaxes(
    title_text='Значения'
)

fig.update_yaxes(
    title_text='Частота'
)

new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))

fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))

#сохранение изображения
plt_name = pics_path + 'Распределение значений дохода уходящих и остающихся клиентов.png'      

fig.write_image(
    plt_name,
    width=1400, 
    height=1000
)

fig.show()

Можно выделить следующие достаточно большие сегменты с повышенным оттоком клиентов:

  • клиенты с доходом 100000 - 135000;
  • клиенты с доходом 150000 - 180000;
  • клиенты с доходом 185000 - 215000.

Оценим численность этих сегментов и превышение их оттока относительно среднего показателя.

In [125]:
#границы сегментов
salary_seg_borders = {
        'seg_a':[100000,135000], 
        'seg_b':[150000,180000], 
        'seg_c':[185000,215000], 
    }

#датафрейм с агрегированной информацией по по столбцу
salary_segments = segmentation(salary_seg_borders, df, 'est_salary', 'оценочный доход', total_ch_rate)        

salary_segments
No description has been provided for this image
Out[125]:
churn_rate churn_to_total_churn_diff_% segment_size segment
seg_c 23.16 21.38 652 est_salary_185000-215000
seg_a 21.87 14.62 1765 est_salary_100000-135000
seg_b 21.67 13.57 1126 est_salary_150000-180000

Оценочный средний доход мало влияет на отток: в интервале значений с самым высоким средним процентом оттока этот процент не превышает общий показатель более чем на 21,4%.

Столбец score¶

In [128]:
#график совместного распределения 
fig = px.histogram(
    df,
    x='score',
    width=1400,
    height=1000,
    color='churn',
    nbins=100,
    color_discrete_sequence=['steelblue','grey'],
    histnorm='probability density',
    barmode='overlay',
    labels={'churn':'Признак оттока'},
    title='Распределение значений балла кредитного скоринга уходящих и остающихся клиентов'
)

fig.update_xaxes(
    title_text='Значения'
)

fig.update_yaxes(
    title_text='Частота'
)

new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))

fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))

#сохранение изображения
plt_name = pics_path + 'Распределение значений балла кредитного скоринга уходящих и остающихся клиентов.png'        

fig.write_image(
    plt_name,
    width=1400, 
    height=1000
)

fig.show()

Можно выделить следующие достаточно большие сегменты с повышенным оттоком клиентов:

  • клиенты с баллом 835 - 909;
  • клиенты с баллом 920 - 944;

Оценим численность этих сегментов и превышение их оттока относительно среднего показателя.

In [130]:
#границы сегментов
score_seg_borders = {
        'seg_a':[850,890], 
        'seg_b':[920,934]
    }

#датафрейм с агрегированной информацией по по столбцу
score_segments = segmentation(score_seg_borders, df, 'score', 'балл скоринга', total_ch_rate)        

score_segments 
No description has been provided for this image
Out[130]:
churn_rate churn_to_total_churn_diff_% segment_size segment
seg_a 28.49 49.31 2043 score_850-890
seg_b 24.58 28.81 472 score_920-934

Можно выделить интервал значений 850-890: средний процент оттока клиентов с таким рейтингом превышает общий показатель на 49,3%.

Столбец age¶

In [133]:
#график совместного распределения 
fig = px.histogram(
    df,
    x='age',
    width=1400,
    height=1000,
    color='churn',
    nbins=df['age'].nunique()*2,
    color_discrete_sequence=['steelblue','grey'],
    histnorm='probability density',
    barmode='overlay',
    labels={'churn':'Признак оттока'},
    title='Распределение значений балла кредитного скоринга уходящих и остающихся клиентов'
)

fig.update_xaxes(
    title_text='Значения'
)

fig.update_yaxes(
    title_text='Частота'
)

new = {'0':'Остающиеся', '1': 'Уходящие'}
fig.for_each_trace(lambda t: t.update(name = new[t.name]))

fig.update_layout(legend=dict(x=0, y=1, xanchor='left', yanchor='top'))


#сохранение изображения
plt_name = pics_path + 'Распределение значений возраста уходящих и остающихся клиентов.png'        

fig.write_image(
    plt_name,
    width=1400, 
    height=1000
)

fig.show()

Можно выделить следующие достаточно большие сегменты с повышенным оттоком клиентов:

  • клиенты в возрасте 25-29 лет;
  • клиенты в возрасте 31-34 лет;
  • клиенты в возрасте 51-60 лет;

Оценим численность этих сегментов и превышение их оттока относительно среднего показателя.

In [135]:
#границы сегментов
age_seg_borders = {
        'seg_a':[25,29], 
        'seg_b':[31,34], 
        'seg_c':[52,59]
    }

#датафрейм с агрегированной информацией по по столбцу
age_segments = segmentation(age_seg_borders, df, 'age', 'возраст', total_ch_rate)        

age_segments 
No description has been provided for this image
Out[135]:
churn_rate churn_to_total_churn_diff_% segment_size segment
seg_c 30.69 60.84 1147 age_52-59
seg_a 26.45 38.63 862 age_25-29
seg_b 21.42 12.25 1354 age_31-34

Наиболее интересный интервал значений возраста - 52-59 лет, клиенты этого возраста имеют средний процент оттока на 60,8% выше общего показателя.

Процент уходящих клиентов по категориальным признакам и влияние этих признаков на отток¶

Сводные таблицы и визуализация¶

In [140]:
#список категориальных признаков
cat_cols = {
    'city':'город',
    'gender':'пол',
    'equity':'количество баллов собственности',
    'products':'количество продуктов',
    'credit_card':'кред. карта',
    'last_activity':'активность'
}


for col in cat_cols.keys():
    
    #создание сводной таблицы
    pivot = pd.DataFrame(df.groupby(col)['churn'].mean()*100)
    pivot['churn_rate_to_total_diff_%'] = pivot.apply(lambda x: (x  / total_ch_rate - 1) *100)
    pivot['churn'] = pivot['churn'].map('{:.2f}'.format).astype('float')
    pivot['churn_rate_to_total_diff_%'] = (
        pivot['churn_rate_to_total_diff_%']
            .map('{:.2f}'.format)
            .astype('float')
    )
    pivot = pivot.sort_values(by='churn', ascending=False).reset_index()
    pivot.columns=[col,'churn_rate','churn_rate_to_total_diff_%']
    pivot['segment_size'] = pivot[col].map(lambda x: df[df[col] == x]['churn'].count())
    pivot = pivot.sort_values(by='churn_rate', ascending=False)
    display(f'Процент оттока для столбца {col}',pivot,'')
    
   #построение диаграммы
    plt.figure(figsize=(15,len(pivot[col].unique())))
    ax = sns.barplot(
        pivot,
        x='churn_rate',
        y=col,
        orient='y',
        order= pivot.sort_values(by='churn_rate', ascending=False)[col]
    )
    plt.axvline(
        total_ch_rate, 
        linestyle='--', 
        color='red',
        label='Общий процент оттока'
    )
    plt.legend(loc='upper center')
    ax.bar_label(ax.containers[0], fontsize=12)
    ax.set_title(f'Процент оттока по: {col}')
    ax.set_xlabel('Процент оттока')
    ax.set_ylabel(f'Признак: {col}');
        
    #сохранение изображения
    plt_name = pics_path + 'Процент оттока по признаку ' + cat_cols[col] + '.png'        
    plt.savefig(
        plt_name,
        bbox_inches= 'tight'
    )

    plt.show();
    display('','')
    
    
'Процент оттока для столбца city'
city churn_rate churn_rate_to_total_diff_% segment_size
0 Ярославль 19.99 4.74 5379
1 Ростов 19.08 -0.02 1363
2 Рыбинск 17.15 -10.10 2501
''
No description has been provided for this image
''
''
'Процент оттока для столбца gender'
gender churn_rate churn_rate_to_total_diff_% segment_size
0 М 25.0 31.03 4568
1 Ж 13.3 -30.27 4675
''
No description has been provided for this image
''
''
'Процент оттока для столбца equity'
equity churn_rate churn_rate_to_total_diff_% segment_size
0 9 53.85 182.21 13
1 7 45.57 138.83 79
2 8 35.29 84.98 17
3 6 35.03 83.61 157
4 5 29.49 54.58 1858
5 4 25.03 31.17 1818
6 3 20.71 8.54 1521
7 2 15.92 -16.58 1043
8 1 14.11 -26.04 659
9 0 3.99 -79.07 2078
''
No description has been provided for this image
''
''
'Процент оттока для столбца products'
products churn_rate churn_rate_to_total_diff_% segment_size
0 4 62.75 228.85 459
1 5 42.11 120.68 19
2 3 28.97 51.85 994
3 2 20.16 5.69 4731
4 1 7.43 -61.04 3040
''
No description has been provided for this image
''
''
'Процент оттока для столбца credit_card'
credit_card churn_rate churn_rate_to_total_diff_% segment_size
0 0 26.21 37.35 3026
1 1 15.62 -18.14 6217
''
No description has been provided for this image
''
''
'Процент оттока для столбца last_activity'
last_activity churn_rate churn_rate_to_total_diff_% segment_size
0 1 25.56 33.97 4816
1 0 12.04 -36.90 4427
''
No description has been provided for this image
''
''

Выводы и наблюдения.¶

Признак city слабо влияет на отток клиентов. Средний процент уходящих клиентов несколько ниже среди клиентов из Рыбинска, равный общему в Ростове, выше - в Ярославле, но отличие от среднего значения невелико.

Признак gender существенно влияет на отток клиентов: процент уходящих клиентов среди мужчин почти вдвое выше, чем среди женщин. Тем не менее, процент уходящих клиентов среди мужчин всего на 31% выше, чем общий показатель по датасету.

То же самое можно сказать о признаке credit_card. Среди клиентов с кредитной картой значительно меньше уходящих, но их средний процент оттока всего на 37,3% выше, чем у клиентов с кредитной картой.

Признак last_activity существенно влияет на отток клиентов: средний процент уходящих клиентов среди активных клиентов вдовое превышает показатель для клиентов без признака. Тем не менее, средний процент уходящих клиентов среди активных всего на 33,9% выше, общий показатель по датасету.

Отток клиентов зависит от числа продуктов (products). Чем больше у клиентов продуктов, тем выше среди них процент оттока. Процент оттока среди клиентов:

  • с количеством продуктов 3 на 52% выше среднего;
  • с количеством продуктов 4 на 228% выше среднего;
  • с количеством продуктов 5 на 120% выше среднего; однако таких клиентов всего 19, имеет смысл объединить их с клиентами из предыдущего интервала.

Отток клиентов зависит от балла собственности (equity). Чем выше балл собственности у клиентов, тем выше среди них процент оттока. Можно выделить следующие категории клиентов с высоким оттоком:

  • клиенты с баллом собственности 5: процент оттока на 54,5% выше среднего;
  • клиенты с баллом собственности 6 и более: процент оттока выше среднего 83,6%-182,1%, сегменты малочисленные, имеет смысл объединить их в один.

Корреляционный анализ¶

Матрица коэффициентов корреляций Пирсона для всего датафрейма и столбца churn.¶

In [146]:
#матрица корреляций
corr_matrix = df[[
    'score',
    'age',
    'equity',
    'balance',
    'products',
    'credit_card',
    'last_activity',
    'est_salary',
    'churn',
    'city__Ростов',
    'city__Рыбинск',
    'city__Ярославль',
    'gender__Ж',
    'gender__М']].corr()


#построение тепловой карты
fig, axs = plt.subplots(1, 2, figsize=(15,10), width_ratios=[4,1])

sns.heatmap(
    corr_matrix, 
    annot=True, 
    cmap='coolwarm', 
    fmt='.2f',
    center=0,
    ax=axs[0]
);

axs[0].set_ylabel('Столбцы')
axs[0].set_xlabel('Столбцы')
axs[0].set_title('Коэффициенты корреляций Пирсона для всего датафрейма')

sns.heatmap(
    (corr_matrix['churn']
          .drop('churn')
          .sort_values(ascending=False)
          .to_frame()), 
    annot=True, 
    cmap='coolwarm', 
    fmt='.2f',
    center=0,
    ax=axs[1]
);

axs[1].set_ylabel('Столбцы')
axs[1].set_xlabel('Признак оттока')
axs[1].set_title('Столбец "сhurn"')

fig.show();
No description has been provided for this image

Нет достаточно сильных взаимосвязей ни между столбцами датафрейма вообще, ни между столбцом churn и значениями переменных.

Матрица коэффициентов корреляций с использованием библиотеки phik для всего датафрейма и столбца churn.¶

In [149]:
#матрица корреляций
phik_corr_matrix = df[[
    'score',
    'age',
    'equity',
    'balance',
    'products',
    'credit_card',
    'last_activity',
    'est_salary',
    'churn',
    'city__Ростов',
    'city__Рыбинск',
    'city__Ярославль',
    'gender__Ж',
    'gender__М'
]].phik_matrix(
    interval_cols=['balance','est_salary'], 
    bins={'balance':100,'est_salary':100}
)

#построение тепловой карты
fig, axs = plt.subplots(1, 2, figsize=(15,10), width_ratios=[4,1])

sns.heatmap(
    phik_corr_matrix, 
    annot=True, 
    cmap='coolwarm', 
    fmt='.2f',
    center=0,
    ax=axs[0]
);

axs[0].set_ylabel('Столбцы')
axs[0].set_xlabel('Столбцы')
axs[0].set_title('Коэффициенты корреляций для всего датафрейма')

sns.heatmap(
    (phik_corr_matrix['churn']
          .drop('churn')
          .sort_values(ascending=False)
          .to_frame()), 
    annot=True, 
    cmap='coolwarm', 
    fmt='.2f',
    center=0,
    ax=axs[1]
);

axs[1].set_ylabel('Столбцы')
axs[1].set_xlabel('Признак оттока')
axs[1].set_title('Столбец "сhurn"')

fig.show();
No description has been provided for this image

Нет достаточно сильной взаимосвязи между столбцом churn и значениями переменных.

Возможна умеренная взаимосвязь между значениями столбцов:

  • balance и score (коэффициент 0,63);
  • products и equity (коэффициент 0,63);
  • balance и est_salary (коэффициент 0,65).

Возможна сильная взаимосвязь между значениями столбцов:

  • est_salary и score (коэффициент 0,71).

Промежуточный вывод о признаках и интервалах значений, которые значимо влияют на отток клиентов¶

Интерес для анализа представляют следующие признаки и интервалы значений, которые значимо влияют на отток клиентов:

  • клиенты с балансом 1,1-2 млн. Средний процент оттока у таких клиентов превышает общий показатель почти вдвое: на 99,2%; количество клиентов 1173;
  • интервал значений 850-890: средний процент оттока клиентов с таким рейтингом превышает общий показатель на 49,3%; количество клиентов 2043;
  • интервал значений возраста - 52-59 лет, клиенты этого возраста имеют средний процент оттока на 60,8% выше общего показателя; количество клиентов 1147;
  • клиенты с количеством продуктов 3 на 52% выше среднего; количество клиентов 994;
  • клиенты с количеством продуктов 4 на 228% выше среднего; количество клиентов 459;
  • клиенты с количеством продуктов 5 на 120% выше среднего; количество клиентов 19;
  • клиенты с баллом собственности 4: процент оттока на 31% выше среднего; количество клиентов 1818;
  • клиенты с баллом собственности 5: процент оттока на 54,5% выше среднего; количество клиентов 1858;
  • клиенты с баллом собственности 6 и более: процент оттока выше среднего 83,6%-182,1%; количество клиентов 266;
  • gender: среди мужчин почти вдвое выше, чем среди женщин. Тем не менее, процент уходящих клиентов среди мужчин всего на 31% выше, чем в среднем по датасету; количество клиентов 4568;
  • credit_card: среди клиентов с кредитной картой значительно больше уходящих, но их процент оттока всего на 37,3% выше, чем у клиентов без кредитной карты; количество клиентов 3026;
  • last_activity: средний процент уходящих клиентов среди активных клиентов вдвое превышает показатель для клиентов без признака. Тем не менее, средний процент уходящих клиентов среди активных всего на 33,9% выше, общий показатель по датасету; количество клиентов 4816.

Статистический анализ данных. Проверка статистических гипотез.¶

Гипотеза о различии дохода между теми клиентами, которые ушли и теми, которые остались.¶

Выбор статистического критерия.

Генеральные совокупности (остающиеся и уходящие клиенты) не зависят друг от друга, нет оснований считать их дисперсии различными. Распределение совокупностей достаточно близко к нормальному, выбросы удалены. Для проверки гипотезы можно воспользоваться тестом Стьюдента (t-тест) для сравнения средних значений двух генеральных совокупностей.

Формулирование гипотез.

Нулевая гипотеза H0: средний доход уходящих клиентов равен среднему доходу остающихся.

Алтернативная гипотеза H1: средний доход уходящих клиентов выше, чем остающихся.

Проведение теста.

In [158]:
#уровень статистической значимости
alpha = 0.05 

#расчёт и вывод p-value
results = st.ttest_ind(
    df[df['churn'] == 1]['est_salary'], 
    df[df['churn'] == 0]['est_salary'], 
    alternative='greater'
)

print(f'p-значение:, {results.pvalue}', '\n')

#результат
if results.pvalue < alpha:
    print('Отвергаем нулевую гипотезу')
else:
    print('Не получилось отвергнуть нулевую гипотезу') 
    
print(
    '\n',
    'Средний доход уходящих клиентов: ', 
    round(df[df['churn'] == 1]['est_salary'].mean(), 2),
    '\n',
    '\n',
    'Средний доход остающихся клиентов: ', 
    round(df[df['churn'] == 0]['est_salary'].mean(), 2),
)
p-значение:, 5.409357060500146e-07 

Отвергаем нулевую гипотезу

 Средний доход уходящих клиентов:  132336.44 
 
 Средний доход остающихся клиентов:  123337.07

Вывод: есть основания считать, что средний доход уходящих клиентов выше, чем остающихся.

Также проверим гипотезу тестом Уилкинсона-Манна-Уитни.

In [160]:
#уровень статистической значимости
alpha = 0.05 

#расчёт и вывод p-value
results = st.mannwhitneyu(
    df[df['churn'] == 1]['est_salary'], 
    df[df['churn'] == 0]['est_salary'], 
    alternative='greater'
)

print(f'p-значение:, {results.pvalue}', '\n')

#результат
if results.pvalue < alpha:
    print('Отвергаем нулевую гипотезу')
else:
    print('Не получилось отвергнуть нулевую гипотезу') 
    
print(
    '\n',
    'Средний доход уходящих клиентов: ', 
    round(df[df['churn'] == 1]['est_salary'].mean(), 2),
    '\n',
    '\n',
    'Средний доход остающихся клиентов: ', 
    round(df[df['churn'] == 0]['est_salary'].mean(), 2),
)
p-значение:, 9.524632238669428e-09 

Отвергаем нулевую гипотезу

 Средний доход уходящих клиентов:  132336.44 
 
 Средний доход остающихся клиентов:  123337.07

Оба теста показали один и тот же результат: есть основания считать, что средний доход уходящих клиентов выше, чем остающихся.

Проверить гипотезу о разнице балла кредитного скоринга уходящих и остающихся клиентов.¶

Выбор статистического критерия.

Генеральные совокупности (остающиеся и уходящие клиенты) не зависят друг от друга, нет оснований считать их дисперсии различными. Распределение совокупностей достаточно близко к нормальному, выбросы удалены. Для проверки гипотезы можно воспользоваться тестом Стьюдента (t-тест) для сравнения средних значений двух генеральных совокупностей.

Формулирование гипотез.

Нулевая гипотеза H0: средний балл кредитного скоринга уходящих клиентов равен среднему баллу кредитного скоринга остающихся.

Алтернативная гипотеза H1: средний балл кредитного скоринга уходящих клиентов выше среднего балла кредитного скоринга остающихся.

Проведение теста.

In [165]:
#уровень статистической значимости
alpha = 0.05 

#расчёт и вывод p-value
results = st.ttest_ind(
    df[df['churn'] == 1]['score'], 
    df[df['churn'] == 0]['score'], 
    alternative='greater'
)

print(f'p-значение:, {results.pvalue}', '\n')

#результат
if results.pvalue < alpha:
    print('Отвергаем нулевую гипотезу')
else:
    print('Не получилось отвергнуть нулевую гипотезу') 
    
print(
    '\n',
    'Средний балл кредитного скоринга уходящих клиентов: ', 
    round(df[df['churn'] == 1]['score'].mean(), 2),
    '\n',
    '\n',
    'Средний балл кредитного скоринга остающихся клиентов: ', 
    round(df[df['churn'] == 0]['score'].mean(), 2),
)
p-значение:, 5.99679738606846e-29 

Отвергаем нулевую гипотезу

 Средний балл кредитного скоринга уходящих клиентов:  861.68 
 
 Средний балл кредитного скоринга остающихся клиентов:  843.37

Вывод: есть основания считать, что средний балл кредитного скоринга уходящих клиентов выше, чем остающихся.

Гипотеза о более высоком балансе уходящих клиентов относительно остающихся.¶

Выбор статистического критерия.

Генеральные совокупности (остающиеся и уходящие клиенты) не зависят друг от друга. Распределение совокупностей скошено, присутствуют выбросы. Для проверки гипотезы можно воспользоваться тестом Уилкоксона-Манна-Уитни для сравнения средних значений двух генеральных совокупностей.

Формулирование гипотез.

Нулевая гипотеза H0: средний баланс уходящих клиентов равен среднему балансу остающихся.

Алтернативная гипотеза H1: средний баланс уходящих клиентов выше среднего баланса остающихся.

Проведение теста.

In [168]:
#уровень статистической значимости
alpha = 0.05 

#расчёт и вывод p-value
results = st.mannwhitneyu(
    df[df['churn'] == 1]['balance'].dropna(), 
    df[df['churn'] == 0]['balance'].dropna(), 
    alternative='greater'
)

print('p-значение:', results.pvalue, '\n')

#результат
if results.pvalue < alpha:
    print('Отвергаем нулевую гипотезу')
else:
    print('Не получилось отвергнуть нулевую гипотезу') 
    
print(
    '\n',
    'Средний баланс уходящих клиентов: ', 
    round(df[df['churn'] == 1]['balance'].mean(), 2),
    '\n',
    '\n',
    'Средний баланс остающихся клиентов: ', 
    round(df[df['churn'] == 0]['balance'].mean(), 2),
)
p-значение: 2.009667769752643e-67 

Отвергаем нулевую гипотезу

 Средний баланс уходящих клиентов:  1017219.31 
 
 Средний баланс остающихся клиентов:  673232.07

Вывод: есть основания считать, что средний баланс уходящих клиентов выше, чем остающихся.

Вывод по разделу статистического анализа данных¶

Результаты проверки гипотез.

  1. Есть основания считать, что средний доход уходящих клиентов выше, чем остающихся.

  2. Есть основания считать, что средний балл кредитного скоринга уходящих клиентов выше, чем остающихся.

  3. Есть основания считать, что средний баланс уходящих клиентов выше, чем остающихся.

Сегментация клиентов, склонных к оттоку, на основе показателей, выявленных на этапе исследовательского анализа данных. Приоритизация и описание полученных сегментов.¶

Уточним показатели полученных в разделе исследовательского анализа данных интервалов, используя признаки gender, credit_card и last_activity, чьи значения среднего процента оттока распределены достаточно контрастно относительно общего значения оттока.

In [173]:
#список границ интервалов
conditions = [
    '1100000 <= balance <= 2000000', 
    '850 <= score <= 890',
    '52 <= age <= 59',
    'products == 3',
    'products >= 4',
    'equity == 4',
    'equity == 5',
    'equity >= 6'
]

#список бинарных признаков
attributes_list = [
    'gender',
    'credit_card',
    'last_activity'
]

#построение и вывод сводных таблиц процента уходящих клиентов в  интервалах
for condition in conditions:
    total=pd.DataFrame()
    display(f'Интервал "{condition}":')
    for attribute in attributes_list:
        pivot = df.query(condition).groupby(attribute).agg({'churn':['mean','count']})
        pivot.columns = ['churn_rate','segment_size']
        pivot['churn_rate'] = round(pivot['churn_rate'] * 100, 1)
        pivot['churn_to_total_diff_%'] = round((pivot['churn_rate'] / total_ch_rate - 1) * 100, 2)
        pivot = pivot.reset_index()
        pivot['index'] = pivot[attribute].map(lambda x: attribute + '_' + str(x))
        pivot = pivot.set_index('index').drop(columns=[attribute])
        total = pd.concat([total,pivot])
    display(total,'')
'Интервал "1100000 <= balance <= 2000000":'
churn_rate segment_size churn_to_total_diff_%
index
gender_Ж 28.2 592 47.80
gender_М 48.0 581 151.57
credit_card_0 41.7 573 118.55
credit_card_1 34.5 600 80.82
last_activity_0 23.9 561 25.26
last_activity_1 51.0 612 167.30
''
'Интервал "850 <= score <= 890":'
churn_rate segment_size churn_to_total_diff_%
index
gender_Ж 21.9 1081 14.78
gender_М 35.9 962 88.16
credit_card_0 36.1 815 89.20
credit_card_1 23.5 1228 23.17
last_activity_0 11.6 1040 -39.20
last_activity_1 46.0 1003 141.09
''
'Интервал "52 <= age <= 59":'
churn_rate segment_size churn_to_total_diff_%
index
gender_Ж 25.1 650 31.55
gender_М 38.0 497 99.16
credit_card_0 39.6 477 107.55
credit_card_1 24.3 670 27.36
last_activity_0 23.1 555 21.07
last_activity_1 37.8 592 98.11
''
'Интервал "products == 3":'
churn_rate segment_size churn_to_total_diff_%
index
gender_Ж 21.3 578 11.64
gender_М 39.7 416 108.07
credit_card_0 30.2 643 58.28
credit_card_1 26.8 351 40.46
last_activity_0 20.4 579 6.92
last_activity_1 41.0 415 114.88
''
'Интервал "products >= 4":'
churn_rate segment_size churn_to_total_diff_%
index
gender_Ж 55.1 187 188.78
gender_М 66.3 291 247.48
credit_card_0 42.7 150 123.79
credit_card_1 70.7 328 270.55
last_activity_0 91.0 100 376.94
last_activity_1 54.2 378 184.07
''
'Интервал "equity == 4":'
churn_rate segment_size churn_to_total_diff_%
index
gender_Ж 17.2 942 -9.85
gender_М 33.4 876 75.05
credit_card_0 33.3 672 74.53
credit_card_1 20.2 1146 5.87
last_activity_0 15.2 870 -20.34
last_activity_1 34.1 948 78.72
''
'Интервал "equity == 5":'
churn_rate segment_size churn_to_total_diff_%
index
gender_Ж 19.2 923 0.63
gender_М 39.7 935 108.07
credit_card_0 36.7 761 92.35
credit_card_1 24.5 1097 28.41
last_activity_0 16.5 884 -13.52
last_activity_1 41.3 974 116.46
''
'Интервал "equity >= 6":'
churn_rate segment_size churn_to_total_diff_%
index
gender_Ж 27.5 120 44.13
gender_М 48.6 146 154.72
credit_card_0 49.5 111 159.43
credit_card_1 31.6 155 65.62
last_activity_0 20.5 112 7.44
last_activity_1 52.6 154 175.68
''

Исходя из полученных данных о соотношении показателей уточним условия для потенциальных сегментов и объединим интервалы со сходными характеристиками. Это будут сегменты со следующими показателями:

  • баланс между 1100000 и 2000000, активные;
  • балл кредитного скоринга 850-890, активные;
  • возраст 52-59, активные;
  • клиенты с 3 продуктами и более, активные;
  • клиенты с 4 продуктами и более;
  • клиенты с 4 баллами собственности, мужчины, активные;
  • клиенты с 5 баллами собственности, мужчины, активные;
  • клиенты с 6 баллами собственности и более.
In [175]:
#список условий, по которым выделяются сегменты
conditions = [
    '(1100000 <= balance <= 2000000) &  (last_activity == 1)', 
    '(850 <= score <= 890)  & (last_activity == 1)',
    '(52 <= age <= 59)  & (last_activity == 1)',
    '(products == 3) &  (last_activity == 1)',
    '(products >= 4)',
    '(equity == 4) & (last_activity == 1) & (gender == "М")',
    '(equity == 5) & (last_activity == 1) & (gender == "М")',
    '(equity >= 6)',

    
]

#построение таблицы с характеристиками сегментов
total = pd.DataFrame()
for condition in conditions:
    row = df.query(condition).agg({'churn':['mean','count']}).T
    row.columns = ['churn_rate','segment_size']
    row['churn_clients'] = row['churn_rate'] * row['segment_size']
    row['non_churn_clients'] = row['segment_size'] - row['churn_clients']
    row['churn_rate'] = round(row['churn_rate'] * 100, 2)
    row['churn_to_total_diff_%'] = round(
        (row['churn_rate'] / total_ch_rate - 1) * 100, 
        2
    )
    row['condition'] = [condition]
    total = pd.concat([total,row])

total = (
    total
        .sort_values(by='churn_rate', ascending=False)
        .reset_index(drop=True)
)
display(total.head(20))

total.to_excel(pics_path + 'Сегменты до отбора.xlsx', index=False)
churn_rate segment_size churn_clients non_churn_clients churn_to_total_diff_% condition
0 61.92 478.0 296.0 182.0 224.53 (products >= 4)
1 52.07 482.0 251.0 231.0 172.90 (equity == 5) & (last_activity == 1) & (gender == "М")
2 50.98 612.0 312.0 300.0 167.19 (1100000 <= balance <= 2000000) & (last_activity == 1)
3 45.96 1003.0 461.0 542.0 140.88 (850 <= score <= 890) & (last_activity == 1)
4 42.60 446.0 190.0 256.0 123.27 (equity == 4) & (last_activity == 1) & (gender == "М")
5 40.96 415.0 170.0 245.0 114.68 (products == 3) & (last_activity == 1)
6 39.10 266.0 104.0 162.0 104.93 (equity >= 6)
7 37.84 592.0 224.0 368.0 98.32 (52 <= age <= 59) & (last_activity == 1)

По таблице видно, что интерес представляют первые четыре сегмента. Остальные четыре имеют сравнительно меньший средний процент оттока и при этом меньший размер.

In [177]:
#удаление менее приоритетных сегментов
total_segments = total[0:4]

#итоговый список сегментов
total_segments

total_segments.to_excel(pics_path + 'Сегменты после отбора.xlsx', index=False)

Описание полученных сегментов.

  1. Клиенты с количеством продуктов 4 и более.

    • размер сегмента: 478;
    • средний процент оттока: 61,9%;
    • уходящих клиентов: 296;
    • остающихся клиентов: 182.
  2. Мужчины с баллом собственности 5, проявлявшие активность в последнее время.

    • размер сегмента: 482;
    • средний процент оттока: 52,1%;
    • уходящих клиентов: 251;
    • остающихся клиентов: 231.
  3. Клиенты с балансом 1,1 - 2 млн, проявлявшие активность в последнее время.

    • размер сегмента: 612;
    • средний процент оттока: 51%;
    • уходящих клиентов: 312;
    • остающихся клиентов: 300.
  4. Клиенты с баллом кредитного скоринга 850-890, проявлявшие активность в последнее время.

    • размер сегмента: 1003;
    • средний процент оттока: 46%;
    • уходящих клиентов: 461;
    • остающихся клиентов: 542.

Общий вывод¶

Цель проекта: изучить пользователей банка и сформулировать рекомендации отделу маркетинга по возврату ушедших клиентов и удержанию тех, кто находится в “зоне риска” и склонны к уходу.

Общий результат исследования¶

Результаты проверки статистических гипотез.¶

  1. Есть основания считать, что средний доход уходящих клиентов выше, чем остающихся.

  2. Есть основания считать, что средний балл кредитного скоринга уходящих клиентов выше, чем остающихся.

  3. Есть основания считать, что средний баланс уходящих клиентов выше, чем остающихся.

Выявлены следующие сегменты клиентов, склонных к уходу.

  1. Клиенты с количеством продуктов 4 и более.

    • размер сегмента: 478;
    • средний процент оттока: 61,9%;
    • уходящих клиентов: 296;
    • остающихся клиентов: 182.
  2. Мужчины с баллом собственности 5, проявлявшие активность в последнее время.

    • размер сегмента: 482;
    • средний процент оттока: 52,1%;
    • уходящих клиентов: 251;
    • остающихся клиентов: 231.
  3. Клиенты с балансом 1,1 - 2 млн, проявлявшие активность в последнее время.

    • размер сегмента: 612;
    • средний процент оттока: 51%;
    • уходящих клиентов: 312;
    • остающихся клиентов: 300.
  4. Клиенты с баллом кредитного скоринга 850-890, проявлявшие активность в последнее время.

    • размер сегмента: 1003;
    • средний процент оттока: 46%;
    • уходящих клиентов: 461;
    • остающихся клиентов: 542.

Рекомендации для отдела маркетинга.¶

  1. Клиенты с количеством продуктов 4 и более.

    Запустить программу лояльности: при наличии у клиентов 4 продуктов банка и более снизить для них стоимость обслуживания продуктов и предложить продукты на более выгодных условиях (например, вклады с повышенной ставкой и кредиты с пониженной ставкой).

  2. Мужчины с баллом собственности 5, проявлявшие активность в последнее время.

    Предложить таким клиентам программы страхования объектов собственности (недвижимость, автомобили, вклады и т.д.) на более выгодных условиях.

    Предложить таким клиентам кредиты под залог имущества по сниженной ставке.

  3. Клиенты с балансом 1,1 - 2 млн, проявлявшие активность в последнее время.

    Запустить программу лояльности: при балансе средств выше определённого порога присваивать клиентам особый статус, позволяющий приобретать особые, разработанные специально для них продукты банка: вклады с повышенной ставкой и кредиты с пониженной ставкой, льготные условия страхования вкладов.

  4. Клиенты с баллом кредитного скоринга 850-890, проявлявшие активность в последнее время.

    Устроить акцию: клиентам с кредитным скорингом выше 850 снизить ставку по кредитам и/или предложить программы льготного кредитования и реструктуризации задолженности.

О качестве данных.¶

В датасете присутствуют пропуски.

Столбец age. Пропусков в столбце незначительное количество: 26 (0.26%).

Столбец balance. Пропусков в столбце много: 2293 (22.95%).

Наличие пропусков в столбце balance зависит от имеющихся данных, но в рамках групп, от которых зависит их количество, они распределены случайно, что позволяет отнести их к категории MAR.

80,56% пропусков относятся к группе клиентов с баллом собственности 0 и количеством продуктов 1. Возможно, речь идёт о группе малоимущих клиентов либо клиентов, которые мало пользуются продуктами банка - причину пропусков желательно уточнить и принять меры против их появления в дальнейшем.